从实际案例聊聊 Java 应用的 GC 优化

从实际案例聊聊 Java 应用的 GC 优化

当 Java 程序性能达不到既定目标,且其他优化手段都已经穷尽时,通常需要调整垃圾回收器来进一步提高性能,称为 GC 优化。但 GC 算法复杂,影响 GC 性能的参数众多,且参数调整又依赖于应用各自的特点,这些因素很大程度上增加了 GC 优化的难度。即便如此,GC 调优也不是无章可循,仍然有一些通用的思考方法。本篇会介绍这些通用的 GC 优化策略和相关实践案例,主要包括如下内容:

优化前准备: 简单回顾 JVM 相关知识、介绍 GC 优化的一些通用策略。
优化方法: 介绍调优的一般流程:明确优化目标→优化→跟踪优化结果。
优化案例: 简述笔者所在团队遇到的 GC 问题以及优化方案。

一、优化前的准备

GC 优化需知

为了更好地理解本篇所介绍的内容,你需要了解如下内容。

  1. GC 相关基础知识,包括但不限于:
    a) GC 工作原理。
    b) 理解新生代、老年代、晋升等术语含义。
    c) 可以看懂 GC 日志。

  2. GC 优化不能解决一切性能问题,它是最后的调优手段。

如果对第一点中提及的知识点不是很熟悉,可以先阅读小结 - JVM 基础回顾;如果已经很熟悉,可以跳过该节直接往下阅读。

JVM 基础回顾

JVM 内存结构

简单介绍一下 JVM 内存结构和常见的垃圾回收器。

当代主流虚拟机(Hotspot VM)的垃圾回收都采用 “分代回收” 的算法。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。

Hotspot VM 将内存划分为不同的物理区,就是 “分代” 思想的体现。如图所示,JVM 内存主要由新生代、老年代、永久代构成。

① 新生代(Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称 Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。

新生代内又分三个区:一个 Eden 区,两个 Survivor 区(一般而言),大部分对象在 Eden 区中生成。当 Eden 区满时,还存活的对象将被复制到两个 Survivor 区(中的一个)。当这个 Survivor 区满时,此区的存活且不满足 “晋升” 条件的对象将被复制到另外一个 Survivor 区。对象每经历一次 Minor GC,年龄加 1,达到 “晋升年龄阈值” 后,被放到老年代,这个过程也称为 “晋升”。显然,“晋升年龄阈值” 的大小直接影响着对象在新生代中的停留时间,在 Serial 和 ParNew GC 两种回收器中,“晋升年龄阈值”通过参数 MaxTenuringThreshold 设定,默认值为 15。

② 老年代(Old Generation):在新生代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称 Major GC)通常使用 “标记 - 清理” 或“标记 - 整理”算法。整堆包括新生代和老年代的垃圾回收称为 Full GC(HotSpot VM 里,除了 CMS 之外,其它能收集老年代的 GC 都会同时收集整个 GC 堆,包括新生代)。

③ 永久代(Perm Generation):主要存放元数据,例如 Class、Method 的元信息,与垃圾回收要回收的 Java 对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。

常见垃圾回收器

不同的垃圾回收器,适用于不同的场景。常用的垃圾回收器:

  • 串行(Serial)回收器是单线程的一个回收器,简单、易实现、效率高。
  • 并行(ParNew)回收器是 Serial 的多线程版,可以充分的利用 CPU 资源,减少回收的时间。
  • 吞吐量优先(Parallel Scavenge)回收器,侧重于吞吐量的控制。
  • 并发标记清除(CMS,Concurrent Mark Sweep)回收器是一种以获取最短回收停顿时间为目标的回收器,该回收器是基于 “标记 - 清除” 算法实现的。

GC 日志

每一种回收器的日志格式都是由其自身的实现决定的,换而言之,每种回收器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个回收器的日志都维持一定的共性。JavaGC 日志 中简单介绍了这些共性。

参数基本策略

各分区的大小对 GC 的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。

活跃数据的大小是指,应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是 Full GC 后堆中老年代占用空间的大小。可以通过 GC 日志中 Full GC 之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取 GC 数据,通过取平均值的方式计算活跃数据的大小。活跃数据和各分区之间的比例关系如下(见参考文献 1):

空间 倍数
总大小 3-4 倍活跃数据的大小
新生代 1-1.5 活跃数据的大小
老年代 2-3 倍活跃数据的大小
永久代 1.2-1.5 倍 Full GC 后的永久代空间占用

例如,根据 GC 日志获得老年代的活跃数据大小为 300M,那么各分区大小可以设为:

总堆:1200MB = 300MB × 4 新生代:450MB = 300MB × 1.5
老年代: 750MB = 1200MB - 450MB*

这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求。

二、优化步骤

GC 优化一般步骤可以概括为:确定目标、优化参数、验收结果。

确定目标

明确应用程序的系统需求是性能优化的基础,系统的需求是指应用程序运行时某方面的要求,譬如:

  • 高可用,可用性达到几个 9。
  • 低延迟,请求必须多少毫秒内完成响应。
  • 高吞吐,每秒完成多少次事务。

明确系统需求之所以重要,是因为上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。

由于笔者所在团队主要关注高可用和低延迟两项指标,所以接下来分析,如何量化 GC 时间和频率对于响应时间和可用性的影响。通过这个量化指标,可以计算出当前 GC 情况对服务的影响,也能评估出 GC 优化后对响应时间的收益,这两点对于低延迟服务很重要。

举例:假设单位时间 T 内发生一次持续 25ms 的 GC,接口平均响应时间为 50ms,且请求均匀到达,根据下图所示:

那么有 (50ms+25ms)/T 比例的请求会受 GC 影响,其中 GC 前的 50ms 内到达的请求都会增加 25ms,GC 期间的 25ms 内到达的请求,会增加 0-25ms 不等,如果时间 T 内发生 N 次 GC,受 GC 影响请求占比 =(接口响应时间 + GC 时间)×N/T 。可见无论降低单次 GC 时间还是降低 GC 次数 N 都可以有效减少 GC 对响应时间的影响。

优化

通过收集 GC 信息,结合系统需求,确定优化方案,例如选用合适的 GC 回收器、重新设置内存比例、调整 JVM 参数等。

进行调整后,将不同的优化方案分别应用到多台机器上,然后比较这些机器上 GC 的性能差异,有针对性的做出选择,再通过不断的试验和观察,找到最合适的参数。

验收优化结果

将修改应用到所有服务器,判断优化结果是否符合预期,总结相关经验。

接下来,我们通过三个案例来实践以上的优化流程和基本原则(本文中三个案例使用的垃圾回收器均为 ParNew+CMS,CMS 失败时 Serial Old 替补)。

三、GC 优化案例

案例一 Major GC 和 Minor GC 频繁

确定目标

服务情况:Minor GC 每分钟 100 次 ,Major GC 每 4 分钟一次,单次 Minor GC 耗时 25ms,单次 Major GC 耗时 200ms,接口响应时间 50ms。

由于这个服务要求低延时高可用,结合上文中提到的 GC 对服务响应时间的影响,计算可知由于 Minor GC 的发生,12.5% 的请求响应时间会增加,其中 8.3% 的请求响应时间会增加 25ms,可见当前 GC 情况对响应时间影响较大。

(50ms+25ms)× 100 次 / 60000ms = 12.5%,50ms × 100 次 / 60000ms = 8.3%

优化目标:降低 TP99、TP90 时间。

优化

首先优化 Minor GC 频繁问题。通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此可以通过增大新生代空间来降低 Minor GC 的频率。例如在相同的内存分配率的前提下,新生代中的 Eden 区增加一倍,Minor GC 的次数就会减少一半。

这时很多人有这样的疑问,扩容 Eden 区虽然可以减少 Minor GC 的次数,但会增加单次 Minor GC 时间么?根据上面公式,如果单次 Minor GC 时间也增加,很难保证最后的优化效果。我们结合下面情况来分析,单次 Minor GC 时间主要受哪些因素影响?是否和新生代大小存在线性关系?
首先,单次 Minor GC 时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到 Survivor 区)如下图。(注:这里为了简化问题,我们认为 T1 只扫描新生代判断对象是否存活的时间,其实该阶段还需要扫描部分老年代,后面案例中有详细描述。)

  • 扩容前:新生代容量为 R ,假设对象 A 的存活时间为 750ms,Minor GC 间隔 500ms,那么本次 Minor GC 时间 = T1(扫描新生代 R)+T2(复制对象 A 到 S)。

  • 扩容后:新生代容量为 2R ,对象 A 的生命周期为 750ms,那么 Minor GC 间隔增加为 1000ms,此时 Minor GC 对象 A 已不再存活,不需要把它复制到 Survivor 区,那么本次 GC 时间 = 2 × T1(扫描新生代 R),没有 T2 复制时间。

可见,扩容后,Minor GC 时增加了 T1(扫描时间),但省去 T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。下面需要确认下服务中对象的生命周期分布情况:

通过上图 GC 日志中两处红色框标记内容可知:

  1. new threshold = 2(动态年龄判断,对象的晋升年龄阈值为 2),对象仅经历 2 次 Minor GC 后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的 Major GC。
  2. Major GC 后老年代使用空间为 300M+,意味着此时绝大多数 (86% = 2G/2.3G) 的对象已经不再存活,也就是说生命周期长的对象占比很小。

由此可见,服务中存在大量短期临时对象,扩容新生代空间后,Minor GC 频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC 频率自然也会降低。

优化结果

通过扩容新生代为为原来的三倍,单次 Minor GC 时间增加小于 5ms,频率下降了 60%,服务响应时间 TP90,TP99 都下降了 10ms+,服务可用性得到提升。

调整前:

调整后:

小结

如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

更多思考

关于上文中提到晋升年龄阈值为 2,很多同学有疑问,为什么设置了 MaxTenuringThreshold=15,对象仍然仅经历 2 次 Minor GC,就晋升到老年代?这里涉及到 “动态年龄计算” 的概念。

动态年龄计算:Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值。在本案例中,调优前:Survivor 区 = 64M,desired survivor = 32M,此时 Survivor 区中 age<=2 的对象累计大小为 41M,41M 大于 32M,所以晋升年龄阈值被设置为 2,下次 Minor GC 时将年龄超过 2 的对象被晋升到老年代。

JVM 引入动态年龄计算,主要基于如下两点考虑:

  1. 如果固定按照 MaxTenuringThreshold 设定的阈值作为晋升条件:
    a)MaxTenuringThreshold 设置的过大,原本应该晋升的对象一直停留在 Survivor 区,直到 Survivor 区溢出,一旦溢出发生,Eden+Svuvivor 中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。
    b)MaxTenuringThreshold 设置的过小,“过早晋升” 即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的 Major GC。分代回收失去了意义,严重影响 GC 性能。

  2. 相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。

总结来说,为了更好的适应不同程序的内存情况,虚拟机并不总是要求对象年龄必须达到 Maxtenuringthreshhold 再晋级老年代。

案例二 请求高峰期发生 GC,导致服务可用性下降

确定目标

GC 日志显示,高峰期 CMS 在重标记(Remark)阶段耗时 1.39s。Remark 阶段是 Stop-The-World(以下简称为 STW)的,即在执行垃圾回收时,Java 应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低 Remark 时间。

优化

解决问题前,先回顾一下 CMS 的四个主要阶段,以及各个阶段的工作内容。下图展示了 CMS 各个阶段可以标记的对象,用不同颜色区分。

  1. Init-mark 初始标记 (STW) ,该阶段进行可达性分析,标记 GC ROOT 能直接关联到的对象,所以很快。
  2. Concurrent-mark 并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。
  3. Remark 重标记 (STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要 STW 的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。
  4. 并发清理,进行并发的垃圾清理。

可见,Remark 阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?CMS 对老年代做回收,Remark 阶段仅扫描老年代是否可行?结论是不可行,原因如下:

如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象 A 因为引用存在新生代中,它在 Remark 阶段就不会被修正标记为可达,GC 时会被错误回收。
新生代对象持有老年代中对象的引用,这种情况称为 “跨代引用”。因它的存在,Remark 阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。

灰色对象已经不可达,但仍然需要扫描的原因:新生代 GC 和老年代的 GC 是各自分开独立进行的,只有 Minor GC 时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在 Minor GC 发生前不会被标记为不可达,CMS 也无法辨认哪些对象存活,只能全堆扫描(新生代 + 老年代)。由此可见堆中对象的数目影响了 Remark 阶段耗时。
分析 GC 日志可以得出同样的规律,Remark 耗时 > 500ms 时,新生代使用率都在 75% 以上。这样降低 Remark 阶段耗时问题转换成如何减少新生代对象数量。

新生代中对象的特点是 “朝生夕灭”,这样如果 Remark 前执行一次 Minor GC,大部分对象就会被回收。CMS 就采用了这样的方式,在 Remark 前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在 Eden 区使用超过 2M 时启动,当然 2M 是默认的阈值,可以通过参数修改。如果此阶段执行时等到了 Minor GC,那么上述灰色对象将被回收,Reamark 阶段需要扫描的对象就少了。

除此之外 CMS 为了避免这个阶段没有等到 Minor GC 而陷入无限等待,提供了参数 CMSMaxAbortablePrecleanTime ,默认为 5s,含义是如果可中断的预清理执行超过 5s,不管发没发生 Minor GC,都会中止此阶段,进入 Remark。
根据 GC 日志红色标记 2 处显示,可中断的并发预清理执行了 5.35s,超过了设置的 5s 被中断,期间没有等到 Minor GC ,所以 Remark 时新生代中仍然有很多对象。

对于这种情况,CMS 提供 CMSScavengeBeforeRemark 参数,用来保证 Remark 前强制进行一次 Minor GC。

优化结果

经过增加 CMSScavengeBeforeRemark 参数,单次执行时间 > 200ms 的 GC 停顿消失,从监控上观察,GCtime 和业务波动保持一致,不再有明显的毛刺。

小结

通过案例分析了解到,由于跨代引用的存在,CMS 在 Remark 阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待 Minor GC 的发生。只是该阶段有时间限制,如果超时等不到 Minor GC,Remark 时新生代仍然有很多对象,我们的调优策略是,通过参数强制 Remark 前进行一次 Minor GC,从而降低 Remark 阶段的时间。

更多思考

案例中只涉及老年代 GC,其实新生代 GC 存在同样的问题,即老年代可能持有新生代对象引用,所以 Minor GC 时也必须扫描老年代。

JVM 是如何避免 Minor GC 时扫描全堆的?
经过统计信息显示,老年代持有新生代对象引用的情况不足 1%,根据这一特性 JVM 引入了卡表(card table)来实现这一目的。如下图所示:

卡表的具体策略是将老年代的空间分成大小为 512B 的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表 3 被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后 Minor GC 时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。

总结来说,CMS 的设计聚焦在获取最短的时延,为此它 “不遗余力” 地做了很多工作,包括尽量让应用程序和 GC 线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。

案例三 发生 Stop-The-World 的 GC

确定目标

GC 日志如下图(在 GC 日志中,Full GC 是用来说明这次垃圾回收的停顿类型,代表 STW 类型的 GC,并不特指老年代 GC),根据 GC 日志可知本次 Full GC 耗时 1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次 STW 回收停顿时间,提高可用性。

优化

首先,什么时候可能会触发 STW 的 Full GC 呢?

  1. Perm 空间不足;
  2. CMS GC 时出现 promotion failed 和 concurrent mode failure(concurrent mode failure 发生的原因一般是 CMS 正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止 CMS,直接进行 Serial Old GC);
  3. 统计得到的 Young GC 晋升到老年代的平均大小大于老年代的剩余空间;
  4. 主动触发 Full GC(执行 jmap -histo:live [pid])来避免碎片问题。

然后,我们来逐一分析一下:

  • 排除原因 2:如果是原因 2 中两种情况,日志中会有特殊标识,目前没有。
  • 排除原因 3:根据 GC 日志,当时老年代使用量仅为 20%,也不存在大于 2G 的大对象产生。
  • 排除原因 4:因为当时没有相关命令执行。
  • 锁定原因 1:根据日志发现 Full GC 后,Perm 区变大了,推断是由于永久代空间不足容量扩展导致的。

找到原因后解决方法有两种:

  1. 通过把 - XX:PermSize 参数和 - XX:MaxPermSize 设置成一样,强制虚拟机在启动的时候就把永久代的容量固定下来,避免运行时自动扩容。
  2. CMS 默认情况下不会回收 Perm 区,通过参数 CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以让 CMS 在 Perm 区容量不足时对其回收。

由于该服务没有生成大量动态类,回收 Perm 区收益不大,所以我们采用方案 1,启动时将 Perm 区大小固定,避免进行动态扩容。

优化结果

调整参数后,服务不再有 Perm 区扩容导致的 STW GC 发生。

小结

对于性能要求很高的服务,建议将 MaxPermSize 和 MinPermSize 设置成一致(JDK8 开始,Perm 区完全消失,转而使用元空间。而元空间是直接存在内存中,不在 JVM 中),Xms 和 Xmx 也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。

四、总结

结合上述 GC 优化案例做个总结:

  1. 首先再次声明,在进行 GC 优化之前,需要确认项目的架构和代码等已经没有优化空间。我们不能指望一个系统架构有缺陷或者代码层次优化没有穷尽的应用,通过 GC 优化令其性能达到一个质的飞跃。
  2. 其次,通过上述分析,可以看出虚拟机内部已有很多优化来保证应用的稳定运行,所以不要为了调优而调优,不当的调优可能适得其反。
  3. 最后,GC 优化是一个系统而复杂的工作,没有万能的调优策略可以满足所有的性能指标。GC 优化必须建立在我们深入理解各种垃圾回收器的基础上,才能有事半功倍的效果。

本文中案例均来北京业务安全中心(也称风控)对接服务的实践经验。同时感谢风控的小伙伴们,是他们专业负责的审阅,才让这篇文章更加完善。对于本文中涉及到的内容,欢迎大家指正和补充。

-------------本文结束感谢您的阅读-------------
Dean Wang wechat